Skip to content

fix(auth): refresh provider credentials per request (ENG-2116) - #421

Closed
lucas-koontz wants to merge 2 commits into
stagingfrom
fix/eng-2116-refresh-active-jwt
Closed

fix(auth): refresh provider credentials per request (ENG-2116)#421
lucas-koontz wants to merge 2 commits into
stagingfrom
fix/eng-2116-refresh-active-jwt

Conversation

@lucas-koontz

@lucas-koontz lucas-koontz commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

User story

As a signed-in Cowork user
I want an active turn to adopt refreshed MindsHub credentials and recover from one isolated authentication refusal
So that long-running work is not discarded or blamed on my account

Why this matters

A long-running turn can retain a ten-minute MindsHub JWT after the desktop refreshes it. The next model call then fails as if the user's session were invalid, discarding substantial work and suggesting a reconnect that cannot repair the stale in-memory credential. Seven observed failures discarded about 1.47 million tokens, including work from a paid external user.

Acceptance criteria

  • An active MindsHub-backed main-session model call reads the refreshed credential without rebuilding the turn; the cross-repo resume handoff applies only when a required planning or coding role uses the runtime MindsHub credential and never blocks direct-provider turns.
  • One typed provider 401 triggers exactly one confirmation attempt with the current credential, and success preserves the turn.
  • Two typed refusals on required planning, coding, or verifier calls propagate as provider_auth; optional probes remain fail-open after confirmation.
  • Generic connection failures, incidental auth-looking text, and bare 401s do not become provider_auth.
  • Existing 402, 403, 429, 5xx, billing, rate-limit, and model-error handling remains unchanged.
  • A comparable post-deployment window has no auth-typed failures for the affected paid install.

How to test

  1. Build an OpenAI provider with token A and a live credential supplier, change the supplier to token B, and verify the next SDK request sends token B.
  2. Return one typed 401 followed by success and verify exactly two calls, normal completion, and no terminal auth error.
  3. Return two typed 401s from required planning, coding, and verifier calls and verify the typed error propagates with the failing role.
  4. Repeat the consecutive failure through router, history-summary, and background-memory probes and verify the required call can still continue.
  5. Return a generic ConnectionError, auth-looking text, a bare 401, and non-auth gateway statuses and verify their existing mappings remain intact.

Notes for the reviewer

Provider instances stay alive while credentials rotate. A runtime supplier feeds the OpenAI SDK before each main-process request. Static provider settings remain static, and exported scratchpad subprocess configuration keeps its construction-time credential because subprocess hot-swap needs a separate IPC contract.

Confirmation is typed and bounded. Only ProviderAuthError is retried, only once, and streaming retries only before the first event. Required planning, coding, and verifier calls propagate a confirmed refusal; optional router, history-summary, and background-memory probes retain their fail-open contract.

The terminal error carries the failing role. cowork-server uses that role to preserve the correct Reconnect or update-key action in mixed-provider configurations.

Merge this first. cowork-server pins this exact staging commit before its own PR can ship.

Verified locally

Check Observed result
Focused auth, client, session, verifier, and dynamic-key pytest union 172 passed
Real OpenAI SDK token A → token B header regression Passed
Mutation isolation for live lookup, one retry, pre-first-event streaming, typed classification, role propagation, required propagation, and optional fail-open behavior Each targeted test failed when its behavior was removed and passed after restoration
Pre-PR sweep and git diff --check Clean

Ships with

Merge order: this Anton PR first, then cowork-server, then cowork desktop.
Deployment: cowork-server is the sole preview/deploy anchor.


Review round — 095c17a

Addresses review feedback from @alecantu7 and Copilot, plus a self-review pass.

  • Azure now refuses an api_key_provider at construction. AsyncAzureOpenAI._prepare_options never chains to super, so the supplier is never awaited and the client sends an empty api-key on every request. Unreachable from cowork-server today, but it accepted the combination silently.
  • The composition test @alecantu7 asked for (test_a_401_on_the_stale_token_is_confirmed_with_the_rotated_one): a 401 on the stale token, the credential rotating mid-flight, a 200 on the retry, asserting the header sequence. Both halves of the fix were tested in isolation and nothing pinned the join. Verified it dies under two independent mutants.
  • Structured-output role stamps covered_generate_object_with's wrapper and its planning / coding roles had no test.
  • Why a confirmed verifier 401 propagates where 402 and 403 on the same call latch quietly is now written down at the clause.

Not changed: Copilot's suggestion to stop mutating exc.role. Every 401 constructs a fresh instance in production and the wrappers never nest; re-wrapping would add a link to the __cause__ chain that turn_errors._http_error_context walks.

Verified: 172 focused, 2,810 full suite.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

It changes core authentication semantics (typed 401 handling + bounded retries) while also upgrading the OpenAI SDK major version, which can have broad runtime impact beyond the touched call sites.

Pull request overview

This PR updates Anton’s provider-auth handling so long-running sessions can transparently recover from a single typed 401 by re-reading refreshed credentials per request (not rebuilding the turn), while ensuring confirmed credential refusals propagate with an attributed failing role.

Changes:

  • Introduces a canonical ProviderAuthError (typed 401) and updates OpenAI/Anthropic mappers to raise it for HTTP 401.
  • Adds a bounded “confirm once” retry at the LLMClient boundary for both non-streaming and streaming calls (streaming retries only before the first event), stamping the failing role on confirmed refusals.
  • Enables OpenAI dynamic credentials via an async API-key supplier, and updates dependency constraints/tests to pin this behavior.
File summaries
File Description
uv.lock Pins openai>=2.21.0 in the lockfile to support async API-key suppliers.
pyproject.toml Updates runtime dependency constraint for OpenAI SDK to >=2.21.0.
anton/cli.py Updates the CLI runtime dependency requirement for OpenAI SDK.
anton/core/llm/provider.py Adds ProviderAuthError(ConnectionError) with optional role attribution.
anton/core/llm/openai.py Maps 401 → ProviderAuthError and supports async API-key suppliers for per-request credential refresh.
anton/core/llm/anthropic.py Maps 401 → ProviderAuthError for canonical auth refusal handling.
anton/core/llm/client.py Implements one-time auth confirmation retry and role attribution for confirmed refusals (including streaming before first event).
anton/core/session.py Updates auth predicate to canonical type and ensures confirmed verifier auth errors aren’t swallowed by broader exception handling.
tests/test_status_error_mapper.py Updates expectations to type-based 401 mapping and pins provider-auth predicate behavior.
tests/test_session_auth_error_reraise.py Ensures the session does not spend retry budget on confirmed auth refusals and uses canonical typing.
tests/test_client.py Adds coverage for confirmation retry, role attribution, and streaming replay guard behavior.
tests/test_thalamus.py Adds coverage that router auth refusal confirms once then fails open (fallback to planning).
tests/test_verifier_truncation.py Ensures confirmed verifier auth refusal is terminal and not retried as truncation.
tests/test_openai_dynamic_api_key.py Regression test proving OpenAI requests re-read the API key without rebuilding the provider.
tests/test_chat_error_action_default.py Pins default UI action behavior to canonical typed auth errors (type-based setup vs retry).
Review details

Suppressed comments (1)

anton/core/llm/client.py:71

  • Same as above: stamping role by mutating the raised ProviderAuthError instance can leak state if the same exception object is reused (e.g., in mocks or cached exceptions). Prefer re-raising a new ProviderAuthError with from exc once confirmation is exhausted.
            if yielded or not confirmation.take():
                exc.role = role
                raise
  • Files reviewed: 14/15 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread anton/core/llm/client.py
@alecantu7
alecantu7 self-requested a review September 1, 2026 15:47

@alecantu7 alecantu7 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial review — a38aa231 against staging 8a555f33

Reviewed as the first link in the ENG-2116 chain (this → cowork-server#428 → cowork#778). Deep pass across all three repos, six dimensions with adversarial verifiers: 11 candidates, 8 refuted, 3 survived — one here (low, inline), one on #428, one on #778.

The fix works. I verified the central claim by executing the composition rather than reading it, through a real openai.AsyncOpenAI client over httpx.MockTransport: a 401 on Bearer token-a with the live credential rotating to token-b at that instant, driven through OpenAIProvider(..., flavor=FLAVOR_MINDS_PASSTHROUGH) and LLMClient.plan().

RESULT: recovered -> done
AUTH HEADERS SEEN: ['Bearer token-a', 'Bearer token-b']

That is the incident, reproduced and recovered. Exactly two calls, second one on the rotated token.

What else came back clean

  • The classifier does not over-reach. Differential-tested against origin/staging's for a corpus of error shapes: 402, 403, 429, 5xx, billing, rate-limit and model-error classifications are byte-identical. No silent reclassification — which was the outcome I was most worried about, since 402 wallet-empty and 429 quota-vs-TPM are distinct user-facing cards in this stack.
  • Negative cases hold: a generic ConnectionError, incidental "invalid api key" text from an unrelated source, and a bare 401 all stay out of the typed path.
  • Required vs optional roles behave as the ticket specifies — two typed refusals surface provider_auth on planning, coding and verifier; router, history-summary and background-memory probes stay fail-open.
  • No retry stacking: the bounded confirmation does not compound with SDK-level max_retries. Real HTTP attempts counted, not inferred.

One candidate I raised and then refuted, recorded so it isn't re-litigated: "a 401 on the end-of-turn completion verifier now kills a turn whose answer already reached the user" — raised high, did not survive.

Not blocking, but worth knowing before this merges first

This PR is fine on its own. The chain around it has one gap, filed on cowork-server#428: that PR narrows provider_auth to the typed ProviderAuthError you introduce here, but hosted/web turns run in a scratchpad pod whose anton image is pinned in scratchpad-controller to 61ec5db6 (staging) / d4f1db2c (prod) — neither of which carries this PR, so both still raise a bare ConnectionError and every hosted 401 downgrades to a generic error. Details there; mentioning it here only because this repo is where the typed error originates and the merge order puts you first.


Verdict: COMMENT. One low finding inline — a coverage gap, not a defect. Not an approval; posting at Alejandro's request rather than as a requested reviewer.



async def test_live_requests_reread_api_key_without_rebuilding_provider():
current_token = "token-a"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW / confirmed / introduced — the one behaviour ENG-2116 was filed for is the one behaviour no test asserts.

This PR's thesis is a composition of two mechanisms: (1) the provider re-reads the credential per request, and (2) LLMClient spends exactly one bounded retry on a typed 401. Both are well tested — in isolation, and neither test touches the other mechanism.

  • This file drives a real openai.AsyncOpenAI over httpx.MockTransport with a rotating token, and proves the per-request re-read. But every response is a 200: grep -n 401 tests/test_openai_dynamic_api_key.py → no match. _call_with_auth_confirmation is never entered.
  • tests/test_client.py (+ test_thalamus.py::test_confirmed_router_auth_failure_falls_back_to_planning) exercises the retry thoroughly — but against AsyncMock(spec=LLMProvider) objects with no credential and no HTTP layer, so "the retry used the NEW token" is not an assertion any of them can make.
  • cowork-server test_build_llm_client.py::test_local_minds_cloud_provider_rereads_runtime_credential awaits the api_key_provider callables directly (token-A → token-B → ProviderAuthError) but never issues a request through them.

grep -rn 'api_key_provider' tests/ across anton and cowork-server returns exactly two files, and neither contains a 401. Nothing tests the join.

This is explicitly not a claim the fix is broken — I executed the composition against unmodified HEAD and it recovers correctly (headers ['Bearer token-a', 'Bearer token-b'], result done). The exposure is refactoring: the two halves could drift apart — retry moved below the provider, credential resolved once per logical call, an except ProviderAuthError added inside OpenAIProvider.complete — and the suite would stay green while the 1.47M-token symptom returned. That matters more here than elsewhere because this repo merges first, so a regression would land unobserved by the other two PRs' tests.

Fix: promote the probe into this file as a second test. No new fixtures needed — it is this test's MockTransport plus a 401 branch and an LLMClient wrapper, ~40 lines. Assert both the recovery and the header sequence; the header sequence is what distinguishes "retried and got lucky" from "retried with the rotated credential".

async def test_a_401_on_the_stale_token_is_confirmed_with_the_rotated_one():
    live = {"cred": "token-a"}
    seen: list[str] = []

    async def api_key_provider() -> str:
        return live["cred"]

    def handler(request: httpx.Request) -> httpx.Response:
        auth = request.headers["authorization"]
        seen.append(auth)
        if auth == "Bearer token-a":
            live["cred"] = "token-b"        # the desktop refresh lands mid-turn
            return httpx.Response(401, json={"error": {"message": "Token is not active", "code": "invalid_token"}})
        return httpx.Response(200, json={...})   # same body as the test above
    # assert the turn completes AND seen == ["Bearer token-a", "Bearer token-b"]

The 401 body is the real one from the incident — Keycloak returned invalid_token / "Token is not active" for all seven named failures — so the fixture reproduces the production shape rather than a lookalike.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TLDR; Agreed, the join was untested. Added your test, near enough verbatim.

Fixed in 095c17aetest_a_401_on_the_stale_token_is_confirmed_with_the_rotated_one uses the invalid_token / "Token is not active" body and asserts ["Bearer token-a", "Bearer token-b"], so it separates "retried and got lucky" from "retried with the rotated credential".

Confirmed it dies under both mutants: the retry wrapper removed from plan(), and the supplier ignored in favour of the static key. The second one also killed the pre-existing rotation test, which is the right shape.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TLDR; You asked for a test on the composition, not the two halves in isolation. Added in 095c17a.

test_a_401_on_the_stale_token_is_confirmed_with_the_rotated_one (tests/test_openai_dynamic_api_key.py:89) serves a 401 on the stale token, rotates the credential mid-flight, and returns 200 on the retry, asserting the header sequence rather than just the recovery. Verified it dies under two independent mutants.

…y seam

AsyncAzureOpenAI._prepare_options never chains to super(), so a callable
api_key is never awaited and the client sends an empty api-key header on
every request. Refuse the combination at construction instead of failing
on the first call.

Add the test for the composition the fix actually depends on: a 401 on the
stale token, the credential rotating mid-flight, and a 200 on the retry,
asserting the header sequence rather than just the recovery. Both halves
were tested in isolation and neither could catch the two drifting apart.

Also cover the structured-output role stamps, and record why a confirmed
verifier 401 propagates where 402 and 403 on the same call latch quietly.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The implementation and tests consistently enforce typed 401 confirmation/propagation behavior and per-request credential refresh, with only a minor docstring grammar nit noted.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

tests/test_openai_dynamic_api_key.py:90

  • Docstring grammar: “The join the two halves …” is ungrammatical; consider rephrasing for clarity.
  • Files reviewed: 14/15 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

@alecantu7 alecantu7 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verification pass — 095c17ae

My finding is closed. test_a_401_on_the_stale_token_is_confirmed_with_the_rotated_one exists, uses the production 401 body (invalid_token / "Token is not active"), and asserts ["Bearer token-a", "Bearer token-b"] — which is what separates retried and got lucky from retried with the rotated credential.

Mutation-verified rather than read: neutralising the per-request supplier at openai.py:870 (client_api_key = api_key) turns 2 tests red, the new one included. Guard armed.

The Azure refusal in the same commit is a better catch than my finding was, and worth stating out loud since it is the kind of thing that would only have surfaced in production: AsyncAzureOpenAI._prepare_options fully overrides the base hook and never chains to super(), so _refresh_api_key never runs and the supplier is never awaited — while the base __init__ has already replaced the callable api_key with "". An Azure install would have sent an empty api-key header and 401'd forever. The ENG-2116 fix would have created that; refusing the combination at construction is right.

One thing to carry to the sibling: cowork-server#428 pins anton to a38aa231, which is this PR's previous head — so it is testing against an anton without the Azure refusal. Raised there; flagging here because the merge order puts you first and the re-pin is downstream of you.

Scope note: at Alejandro's direction this was a verification pass on my finding, so the rest of this commit's ~185 new lines are not reviewed by me.


Verdict: COMMENT. Not an approval.

@alecantu7 alecantu7 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remaining-lines review — 095c17ae

Covering the rest of this commit, which my last pass explicitly excluded. No findings.

The Azure refusal holds up. The diagnosis is true of the SDK the lockfile resolves: AsyncAzureOpenAI._prepare_options fully overrides the base hook and never chains to super(), so _refresh_api_key never runs and the supplier is never awaited — while the base __init__ has already replaced the callable api_key with "". Refusing at construction is the right shape: loud and early, rather than an empty api-key header 401'ing forever at first use.

One candidate was raised and refuted — "the refusal hardcodes an SDK fact already false at the top of anton's declared openai range." The SDK facts checked out; the finding died on three independent grounds.

I also checked the refusal doesn't over-reach into non-Azure deployments via _is_azure_endpoint / api_version, since a false refusal would be a hard startup failure for a working install. It doesn't.

Worth carrying downstream: cowork-server#428 pins anton to a38aa231 — this PR's previous head — so it is testing against an anton without this refusal. Raised there, along with a bigger problem in the same area: providers.py gates the api_key_provider kwarg on credential presence rather than on anton's capability, so the branch = "main" flip that repo's pyproject instructs would TypeError on every signed-in desktop MindsHub turn. Relevant here because the merge order puts you first.


Verdict: COMMENT. Not an approval.

@alecantu7 alecantu7 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving

Reviewed across three rounds — the original fix, the follow-up commit, and a final sweep of the lines the earlier passes had explicitly excluded. No findings outstanding.

What was verified, by execution rather than reading

  • The core fix recovers the actual incident. Driving OpenAIProvider + LLMClient.plan() through a real openai.AsyncOpenAI over httpx.MockTransport, with a 401 on token-a and the credential rotating at that instant: RESULT: recovered -> done, AUTH HEADERS SEEN: ['Bearer token-a', 'Bearer token-b']. Exactly two calls, second on the rotated token.
  • My one finding is closed and armed. test_a_401_on_the_stale_token_is_confirmed_with_the_rotated_one uses the production 401 body (invalid_token / "Token is not active") and asserts the header sequence — which is what separates retried and got lucky from retried with the rotated credential. Neutralising the per-request supplier read at openai.py:870 turns 2 tests red, that one included.
  • The classifier does not over-reach. Differential-tested against origin/staging's for a corpus of error shapes: 402, 403, 429, 5xx, billing, rate-limit and model-error classifications are byte-identical. That was the outcome I was most worried about, since 402 wallet-empty and 429 quota-vs-TPM are distinct user-facing cards in this stack.
  • Required vs optional roles behave as the ticket specifies, and the bounded confirmation does not stack with SDK-level max_retries — real HTTP attempts counted, not inferred.
  • The Azure refusal holds up, including that it does not over-reach into non-Azure deployments via _is_azure_endpoint / api_version. Catching that AsyncAzureOpenAI._prepare_options never chains to super() — so the supplier is never awaited while the base __init__ has already blanked the callable api_key — was a better catch than my own finding was, and it is a hole this fix would otherwise have opened.

Two candidate findings were raised against this PR across the rounds and both died under refutation.

Two things to carry forward — neither blocks this

  1. cowork-server#428 pins anton to a38aa231, this PR's previous head, so it is currently testing against an anton without the Azure refusal. Once this merges, that pin must move to the resulting staging commit — the pin is the seam, and it lives in a different repo.
  2. providers.py in #428 gates the api_key_provider kwarg on credential presence rather than on anton's capability, so the branch = "main" flip its own pyproject.toml instructs would TypeError on every signed-in desktop MindsHub turn. Raised there; noting it here because this PR is what introduces the kwarg, and this is first in the merge order.

Approved on Alejandro's call as reviewer. Merging is his, and the declared order still stands: this first, then cowork-server#428, then cowork#778.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

It changes core authentication error classification and retry semantics across multiple critical call paths (including streaming), so it warrants final human verification despite strong test coverage.

Review details
  • Files reviewed: 14/15 changed files
  • Comments generated: 3
  • Review effort level: Lite

Comment on lines +485 to +493
class ProviderAuthError(ConnectionError):
"""Raised when a provider rejects its credential with HTTP 401.

The type distinguishes an authentication refusal from unrelated
``ConnectionError`` failures. It remains a ``ConnectionError`` subclass so
in-process callers written against the previous 401 mapping keep working.
The client stamps ``role`` on a confirmed refusal so hosts can attribute
the recovery action to the provider that actually failed.
"""

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1e368f16 — the ProviderAuthError docstring now defines terminal refusal as either failed confirmation or the first refusal after streaming output makes replay unsafe.

Comment thread anton/core/session.py
Comment on lines 590 to 597
def _is_provider_auth_error(exc: BaseException) -> bool:
"""A provider-auth 401 — anton's "Invalid API key — …" copy from
`openai.py`/`anthropic.py` (ENG-1310): the credential is wrong, not the
request, so retrying can't succeed either. The substring match mirrors
cowork-server's `turn_errors.is_auth_error()`; the `isinstance` check is
an anton-only narrowing on top of it (both 401 raise sites always type
it this way, so it's a no-op in practice) — anything else (a bare
"temporarily unavailable" ConnectionError) is a different failure.

Shared by both `turn_stream` re-raise sites so the check can't drift
between them (review feedback on ENG-1310).
"""Whether ``exc`` is the canonical provider HTTP-401 mapping.

``LLMClient`` already made the one bounded confirmation attempt before
this reaches the session. The session must therefore propagate this typed
second refusal, while unrelated ``ConnectionError`` values keep their
existing recovery behavior.
"""

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1e368f16 — the session wording now covers both a failed confirmation and a first refusal after streaming output, while preserving unrelated ConnectionError recovery.



async def test_a_401_on_the_stale_token_is_confirmed_with_the_rotated_one():
"""The join the two halves of ENG-2116 are only useful together.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1e368f16 — corrected the docstring to read “The two halves of ENG-2116 are only useful together.”

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Azure callable-key refusal should guard against any callable client_api_key (not only api_key_provider), and two updated docstrings currently misstate the streaming/confirmation contract.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (2)

anton/core/llm/provider.py:492

  • The docstring says the client stamps role only on a “confirmed” refusal, but _stream_with_auth_confirmation also stamps role when a ProviderAuthError occurs after any stream events have been yielded (no retry is attempted to avoid replay). Updating the wording avoids misleading future readers about when role is set.
    The type distinguishes an authentication refusal from unrelated
    ``ConnectionError`` failures. It remains a ``ConnectionError`` subclass so
    in-process callers written against the previous 401 mapping keep working.
    The client stamps ``role`` on a confirmed refusal so hosts can attribute
    the recovery action to the provider that actually failed.

anton/core/session.py:597

  • This docstring claims the session only sees the “typed second refusal” because LLMClient already did the one confirmation attempt, but in streaming flows LLMClient intentionally does not retry once any event has been yielded. A ProviderAuthError reaching the session can therefore be either a failed confirmation or a first post-yield refusal; the wording should cover both.
    ``LLMClient`` already made the one bounded confirmation attempt before
    this reaches the session. The session must therefore propagate this typed
    second refusal, while unrelated ``ConnectionError`` values keep their
    existing recovery behavior.
    """
  • Files reviewed: 14/15 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread anton/core/llm/openai.py
Comment on lines +870 to +886
client_api_key = api_key_provider if api_key_provider is not None else api_key
if api_version and _is_azure_endpoint(base_url):
# Azure OpenAI: use the dedicated client which handles deployment
# URL construction and api-version automatically.
if api_key_provider is not None:
# AsyncAzureOpenAI._prepare_options fully overrides the base
# hook and never chains to super(), so AsyncOpenAI's
# _refresh_api_key never runs and the supplier is never
# awaited. The base __init__ has already replaced a callable
# api_key with "", so the client would send an empty api-key
# header on every request and 401 forever. Refuse at
# construction instead of failing on the first call.
raise EndpointConfigurationError(
"Azure OpenAI cannot refresh credentials per request: "
"AsyncAzureOpenAI ignores a callable api_key. Pass a "
"static api_key for Azure endpoints."
)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and fixed in 303bf7c on the main-targeted hotfix (anton#429). The guard now keys on the resolved value, callable(client_api_key), so a supplier passed through api_key is refused too. Test added: test_azure_refuses_a_callable_passed_as_the_static_api_key.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

It changes core authentication error classification and retry/streaming behavior across the LLM client and session boundary, which warrants final human validation despite strong test coverage.

Review details
  • Files reviewed: 14/15 changed files
  • Comments generated: 3
  • Review effort level: Lite

Comment on lines +488 to +492
The type distinguishes an authentication refusal from unrelated
``ConnectionError`` failures. It remains a ``ConnectionError`` subclass so
in-process callers written against the previous 401 mapping keep working.
The client stamps ``role`` on a confirmed refusal so hosts can attribute
the recovery action to the provider that actually failed.
Comment thread anton/core/session.py
Comment on lines +593 to +596
``LLMClient`` already made the one bounded confirmation attempt before
this reaches the session. The session must therefore propagate this typed
second refusal, while unrelated ``ConnectionError`` values keep their
existing recovery behavior.


async def test_a_401_on_the_stale_token_is_confirmed_with_the_rotated_one():
"""The join the two halves of ENG-2116 are only useful together.
@lucas-koontz

Copy link
Copy Markdown
Contributor Author

Superseded by #429. That PR replayed this change onto main as an ENG-2116 hotfix, and 0164706 Sync main into staging backmerged it, so staging already carries every mechanism here plus three review fixes that only landed on the hotfix branch: the broadened Azure callable(client_api_key) guard, the corrected ProviderAuthError docstring, and the test docstring typo. The remaining delta against staging is 183 commits of unrelated drift, so resolving these conflicts would revert other work to re-land a fix that already shipped. The three Copilot threads here are addressed on staging.

@github-actions github-actions Bot locked and limited conversation to collaborators Sep 2, 2026
@lucas-koontz
lucas-koontz deleted the fix/eng-2116-refresh-active-jwt branch September 2, 2026 21:13
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants